<?php
// +----------------------------------------------------------------------
// | Bwsaas
// +----------------------------------------------------------------------
// | Copyright (c) 2015~2020 http://www.buwangyun.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Gitee ( https://gitee.com/buwangyun/bwsaas )
// +----------------------------------------------------------------------
// | Author: buwangyun <hnlg666@163.com>
// +----------------------------------------------------------------------
// | Date: 2020-9-28 10:55:00
// +----------------------------------------------------------------------

namespace addons\database_manage\service;

use think\facade\Db;
use think\Config;

class DatabaseService
{
    /**
     * 文件指针
     * @var resource
     */
    private $fp;
    /**
     * 备份文件信息 part - 卷号，name - 文件名
     * @var array
     */
    private $file;
    /**
     * 当前打开文件大小
     * @var integer
     */
    private $size = 0;

    /**
     * 数据库配置
     * @var integer
     */
    private $dbconfig = array();
    /**
     * 备份配置
     * @var integer
     */
    private $config = array(
        'path' => './database_backup/',
        //数据库备份路径
        'part' => 20971520,
        //数据库备份卷大小
        'compress' => 1,
        //数据库备份文件是否启用压缩 0不压缩 1 压缩
        'level' => 9,
    );

    /**
     * 数据库备份构造方法
     * @param array $file 备份或还原的文件信息
     * @param array $config 备份配置信息
     */
    public function __construct($config = [])
    {
        $this->config = array_merge($this->config, $config);
        //初始化文件名
        $this->setFile();
        //初始化数据库连接参数
        $this->setDbConn();
        //检查文件是否可写
        if (!$this->checkPath($this->config['path'])) {
            throw new \Exception("The current directory is not writable");
        }
    }

    /**
     * 设置脚本运行超时时间
     * 0表示不限制，支持连贯操作
     */
    public function setTimeout($time = null)
    {
        if (!is_null($time)) {
            set_time_limit($time) || ini_set("max_execution_time", $time);
        }
        return $this;
    }

    /**
     * 设置数据库连接必备参数
     * @param array $dbconfig 数据库连接配置信息
     * @return object
     */
    public function setDbConn($dbconfig = [])
    {
        if (empty($dbconfig)) {
            $this->dbconfig = config('database');
            //$this->dbconfig = Config::get('database');
        } else {
            $this->dbconfig = $dbconfig;
        }
        return $this;
    }

    /**
     * 设置备份文件名
     * @param Array $file 文件名字
     * @return object
     */
    public function setFile($file = null, $force = false)
    {
        //如果不传文件名，生成新的文件名
        if (is_null($file)) {
            $this->file = ['name' => date('Ymd-His'), 'part' => 1];
        } else {
            if ($force) {
                $this->file = $file;
            } elseif (!array_key_exists("name", $file) && !array_key_exists("part", $file)) {
                //如果是文件数组则取文件中第一卷的文件信息
                $this->file = $file['1'];
            } else {
                //如果传了文件名则设置对象为该文件名
                $this->file = $file;
            }
        }
        return $this;
    }

    //数据类连接
    public static function connect()
    {
        return Db::connect();
    }

    //数据库表列表
    public function dataList($table = null, $type = 1)
    {
        $db = self::connect();
        if (is_null($table)) {
            $list = $db->query("SHOW TABLE STATUS");
        } else {
            if ($type) {
                $list = $db->query("SHOW FULL COLUMNS FROM {$table}");
            } else {
                $list = $db->query("show columns from {$table}");
            }
        }
        $list = array_map('array_change_key_case', $list);
        //处理大小显示
        $list =  array_map(function($item){
            $item['data_length_format'] = self::dataFormat($item['data_length']);
            $item['data_free_format'] = self::dataFormat($item['data_free']);
            return $item;
        },$list);
        return $list;

    }

    //得到表字段详情
    public function tableDetail($tablename)
    {
        $db = self::connect();
        $database = env('database.database');
        return $db->query("select * from information_schema.columns where table_name = '" . $tablename . "' and table_schema = '" . $database . "'");
    }


    //数据库备份文件列表
    public function fileList()
    {
        //检测是否存在文件夹，如果不存在，则创建
        if (!is_dir($this->config['path'])) {
            mkdir($this->config['path'], 0755, true);
        }
        $path = realpath($this->config['path']);//相对路径转绝对路径
        $flag = \FilesystemIterator::KEY_AS_FILENAME;
        $glob = new \FilesystemIterator($path, $flag);//得到文件迭代器，里面有文件夹下所有文件
        $list = array();
        foreach ($glob as $name => $file) {
            $info['filename'] = $name;//备份文件名称
            //正则根据备份文件名规则只筛选出备份文件
            if (preg_match('/^\\d{8,8}-\\d{6,6}-\\d+\\.sql(?:\\.gz)?$/', $name)) {
                $name = sscanf($name, '%4s%2s%2s-%2s%2s%2s-%d'); //解析文件名得到日期，备份号，分组等信息
                $date = "{$name[0]}-{$name[1]}-{$name[2]}";//年月日
                $time = "{$name[3]}:{$name[4]}:{$name[5]}";//时分秒
                $part = $name[6];  //分组
                //如果是同个备份下的不同分组，则记录分组数，并累计备份总大小
                if (isset($list["{$date} {$time}"])) {
                    $info = $list["{$date} {$time}"];
                    $info['part'] = max($info['part'], $part);
                    $info['size'] = $info['size'] + $file->getSize();
                } else {
                    //否则正常展示
                    $info['part'] = $part;
                    $info['size'] = $file->getSize();
                }
                $extension = strtoupper(pathinfo($file->getFilename(), PATHINFO_EXTENSION));//只返回文件后缀
                $info['compress'] = $extension === 'SQL' ? '-' : $extension;//标记压缩方式
                $info['time'] = strtotime("{$date} {$time}");//展示创建时间
                $list["{$date} {$time}"] = $info;  //记录数据
            }
        }
        return $list;
    }

    public function getFile($type = '', $time = 0)
    {
        if (!is_numeric($time)) {
            throw new \Exception("{$time} Illegal data type");
        }

        switch ($type) {
            case 'time':  //只返回匹配文件名列表
                $name = date('Ymd-His', $time) . '-*.sql*'; //匹配备份文件的文件名
                $path = realpath($this->config['path']) . DIRECTORY_SEPARATOR . $name; //匹配备份文件的正则表达式
                return glob($path); //根据表达式匹配返回相匹配的文件列表
                break;
            case 'timeverif':  //返回匹配的备份文件列表（有卷号，文件名，是否压缩信息）
                $name = date('Ymd-His', $time) . '-*.sql*'; //匹配备份文件的文件名
                $path = realpath($this->config['path']) . DIRECTORY_SEPARATOR . $name;  //匹配备份文件的正则表达式
                $files = glob($path);  //根据表达式匹配返回相匹配的文件列表
                $list = array();
                //遍历匹配到的备份文件
                foreach ($files as $name) {
                    $basename = basename($name); //得到文件名
                    $match = sscanf($basename, '%4s%2s%2s-%2s%2s%2s-%d'); //解析文件名得到日期，备份号，分组等信息
                    $gz = preg_match('/^\\d{8,8}-\\d{6,6}-\\d+\\.sql.gz$/', $basename); //正则匹配返回是否是压缩文件
                    //以卷号为下标，塞入【卷号，文件名，是否压缩】的信息放入数组
                    $list[$match[6]] = array($match[6], $name, $gz);
                }
                //以卷号从小到大排列
                ksort($list);
                $last = end($list);//取出最末尾卷号
                //如果总卷数等于最末尾卷号 则说明备份文件完好，全部卷都在，返回该数组
                if (count($list) === $last[0]) {
                    return $list;
                } else {
                    //否则说明有卷数丢失，抛异常
                    throw new \Exception("File {$files['0']} may be damaged, please check again");
                }
                break;
            case 'pathname':  //返回当前对象file属性所指的备份路径(相对)
                return "{$this->config['path']}{$this->file['name']}-{$this->file['part']}.sql";
                break;
            case 'filename':  //返回当前对象file属性所指的文件名
                return "{$this->file['name']}-{$this->file['part']}.sql";
                break;
            case 'filepath':  //返回当前对象的备份存放路径
                return $this->config['path'];
                break;
            default:   //默认返回当前对象的文件成员信息
                $arr = array('pathname' => "{$this->config['path']}{$this->file['name']}-{$this->file['part']}.sql", 'filename' => "{$this->file['name']}-{$this->file['part']}.sql", 'filepath' => $this->config['path'], 'file' => $this->file);
                return $arr;
        }
    }

    //删除备份文件
    public function delFile($time)
    {
        if ($time) {
            $file = $this->getFile('time', $time); //返回匹配的文件列表
            //对列表中的每一项备份文件执行删除操作
            array_map("unlink", $file);
            //删完后再检测是否还有文件存留
            if (count($this->getFile('time', $time))) {
                throw new \Exception("File {$time} deleted failed");
            } else {
                return $time;
            }
        } else {
            throw new \Exception("{$time} Time parameter is incorrect");
        }
    }

    /**
     * 下载备份
     * @param string $time
     * @param integer $part
     * @return array|mixed|string
     */
    public function downloadFile($time, $part = 0)
    {
        $file = $this->getFile('time', $time);
        $fileName = $file[$part];
        $is_mul = false;//是否是多文件
        //如果文件大于一个备份文件，则压缩所有备份文件
        if (count($file) > 1) {
            //写入临时文件
            $temp_name = str_replace("-1.sql.gz", "-full.sql.zip", $file[0]);//临时文件名
            $zip = new \ZipArchive();
            $zip->open($temp_name, \ZipArchive::CREATE);   //打开压缩包
            foreach ($file as $part) {
                $zip->addFile($part, basename($part));   //向压缩包中添加文件
            }
            $zip->close();  //关闭压缩包
            $is_mul = true;
        }
        if (file_exists($fileName)) {
            ob_end_clean();
            $filesize = $is_mul ? filesize($temp_name) : filesize($fileName);
            $basename = $is_mul ? basename($temp_name) : basename($fileName);
            header("Cache-Control: must-revalidate, post-check=0, pre-check=0");
            header('Content-Description: File Transfer');
            header('Content-Type: application/octet-stream');
            header('Content-Length: ' . $filesize);
            header('Content-Disposition: attachment; filename=' . $basename);
            $is_mul ? (readfile($temp_name) && @unlink($temp_name)) : readfile($fileName);
        } else {
            throw new \Exception("{$time} File is abnormal");
        }
    }

    public function import($start)
    {
        //还原数据
        $db = self::connect(); //连接
        //是否是压缩文件
        if ($this->config['compress']) { //是压缩文件
            //打开压缩文件句柄，读模式
            $gz = gzopen($this->file[1], 'r');
            $size = 0; //文件长度
        } else {
            //打开普通文件句柄，读模式
            $size = filesize($this->file[1]); //文件长度
            $gz = fopen($this->file[1], 'r');
        }
        $sql = '';
        //如果不是从头读取
        if ($start) {
            //移动读取的指针位置到要读的位置
            $this->config['compress'] ? gzseek($gz, $start) : fseek($gz, $start);
        }

        for ($i = 0; $i < 1000; $i++) {
            //循环读取sql内容
            $sql .= $this->config['compress'] ? gzgets($gz) : fgets($gz);
            //匹配所有单条sql语句
            if (preg_match('/.*;$/', trim($sql))) {
                //如果已执行完，则累计执行进度$start，并置空sql等下一轮执行
                if (false !== $db->execute($sql)) {
                    $start += strlen($sql);
                } else {
                    return false;
                }
                $sql = '';
                //如果不是sql语句，查看是否已执行到文件根部，如果已执行完文件返回0
            } elseif ($this->config['compress'] ? gzeof($gz) : feof($gz)) {
                return 0;
            }
        }
        return array($start, $size);
    }

    /**
     * 写入初始数据
     * @return boolean true - 写入成功，false - 写入失败
     */
    public function Backup_Init()
    {
        $this->dbconfig['hostname'] = $this->dbconfig['hostname'] ?? $this->dbconfig['connections']['mysql']['hostname'];
        $this->dbconfig['hostport'] = $this->dbconfig['hostport'] ?? $this->dbconfig['connections']['mysql']['hostport'];
        $this->dbconfig['database'] = $this->dbconfig['database'] ?? $this->dbconfig['connections']['mysql']['database'];
        $sql = "-- -----------------------------\n";
        $sql .= "-- Think MySQL Data Transfer \n";
        $sql .= "-- \n";
        $sql .= "-- Host     : " . $this->dbconfig['hostname'] . "\n";
        $sql .= "-- Port     : " . $this->dbconfig['hostport'] . "\n";
        $sql .= "-- Database : " . $this->dbconfig['database'] . "\n";
        $sql .= "-- \n";
        $sql .= "-- Part : #{$this->file['part']}\n";
        $sql .= "-- Date : " . date("Y-m-d H:i:s") . "\n";
        $sql .= "-- -----------------------------\n\n";
        $sql .= "SET FOREIGN_KEY_CHECKS = 0;\n\n";
        return $this->write($sql);
    }

    /**
     * 备份表结构
     * @param  string $table 表名
     * @param  integer $start 起始行数
     * @return boolean        false - 备份失败
     */
    public function backup($table, $start)
    {
        $db = self::connect();
        // 备份表结构，$start = 0表示这张表第一次插入
        if (0 == $start) {
            $result = $db->query("SHOW CREATE TABLE `{$table}`");
            $sql = "\n";
            $sql .= "-- -----------------------------\n";
            $sql .= "-- Table structure for `{$table}`\n";
            $sql .= "-- -----------------------------\n";
            $sql .= "DROP TABLE IF EXISTS `{$table}`;\n";
            $sql .= trim($result[0]['Create Table']) . ";\n\n";
            if (false === $this->write($sql)) {
                return false;
            }
        }
        //数据总数
        $result = $db->query("SELECT COUNT(*) AS count FROM `{$table}`");
        $count = $result['0']['count']; //查询当前表数据条数
        //备份表数据
        if ($count) {
            //写入数据注释 ，$start = 0表示这张表第一次插入
            if (0 == $start) {
                $sql = "-- -----------------------------\n";
                $sql .= "-- Records of `{$table}`\n";
                $sql .= "-- -----------------------------\n";
                $this->write($sql);
            }
            //备份数据记录
            $result = $db->query("SELECT * FROM `{$table}` LIMIT {$start}, 1000");
            foreach ($result as $row) {
                $row = array_map('addslashes', $row);
                //写入插入语句
                $sql = "INSERT INTO `{$table}` VALUES ('" . str_replace(array("\r", "\n"), array('\\r', '\\n'), implode("', '", $row)) . "');\n";
                if (false === $this->write($sql)) {
                    return false;
                }
            }
            //还有更多数据
            if ($count > $start + 1000) {
                //return array($start + 1000, $count);
                //有更多数据则继续插入
                return $this->backup($table, $start + 1000);
            }
        }
        //备份下一表
        return 0;
    }

    /**
     * 优化表
     * @param  String $tables 表名
     * @return String $tables
     */
    public function optimize($tables = null)
    {
        if ($tables) {
            $db = self::connect(); //得到连接
            //执行表优化sql
            if (is_array($tables)) {
                $tables = implode('`,`', $tables);
                $list = $db->query("OPTIMIZE TABLE `{$tables}`");
            } else {
                $list = $db->query("OPTIMIZE TABLE `{$tables}`");
            }
            if ($list) {
                return $tables;
            } else {
                throw new \Exception("data sheet'{$tables}'Repair mistakes please try again!");
            }
        } else {
            throw new \Exception("Please specify the table to be repaired!");
        }
    }

    /**
     * 修复表
     * @param  String $tables 表名
     * @return String $tables
     */
    public function repair($tables = null)
    {
        if ($tables) {
            $db = self::connect();
            if (is_array($tables)) {
                $tables = implode('`,`', $tables);
                $list = $db->query("REPAIR TABLE `{$tables}`");
            } else {
                $list = $db->query("REPAIR TABLE `{$tables}`");
            }
            if ($list) {
                return $list;
            } else {
                throw new \Exception("data sheet'{$tables}'Repair mistakes please try again!");
            }
        } else {
            throw new \Exception("Please specify the table to be repaired!");
        }
    }

    /**
     * 写入SQL语句
     * @param  string $sql 要写入的SQL语句
     * @return boolean     true - 写入成功，false - 写入失败！
     */
    private function write($sql)
    {
        $size = strlen($sql); //sql长度
        //由于压缩原因，无法计算出压缩后的长度，这里假设压缩率为50%，
        //一般情况压缩率都会高于50%；
        $size = $this->config['compress'] ? $size / 2 : $size; //如果开启了压缩则长度减半，未开启则照常
        $this->open($size);//得到当前备份文件句柄（指针指向最后写入位置）
        //往备份文件中写入数据
        return $this->config['compress'] ? @gzwrite($this->fp, $sql) : @fwrite($this->fp, $sql);
    }

    /**
     * 打开一个卷，用于写入数据
     * @param  integer $size 写入数据的大小
     */
    private function open($size)
    {
        //存在指针，非初次遍历中
        if ($this->fp) {
            $this->size += $size; //累计当前文件句柄已写长度
            //如果累计压缩长度超过配置的备份长度，则当前备份券已满，开新的备份券写
            if ($this->size > $this->config['part']) {
                //关闭文件资源句柄
                $this->config['compress'] ? @gzclose($this->fp) : @fclose($this->fp);
                //复原文件指针
                $this->fp = null;
                $this->file['part']++;//累计新备份券数量
                session('backup_file', $this->file);//session记录新当前备份券文件信息
                $this->Backup_Init();//将sql写入新的备份券
            }
        } else {
            //不存在指针，初次遍历
            $backuppath = $this->config['path'];//得到备份券文件夹
            $filename = "{$backuppath}{$this->file['name']}-{$this->file['part']}.sql";//拼接备份券生成的sql文件名
            //开启了压缩
            if ($this->config['compress']) {
                $filename = "{$filename}.gz";//更改后缀
                $this->fp = @gzopen($filename, "a{$this->config['level']}");//打开（不存在创建）指定压缩级别写文件句柄
            } else {
                //未开启压缩
                $this->fp = @fopen($filename, 'a');//打开（不存在创建）写文件句柄
            }
            //初始化当前文件句柄已写长度
            $this->size = filesize($filename) + $size;
        }
    }

    /**
     * 检查目录是否可写
     * @param  string $path 目录
     * @return boolean
     */
    protected function checkPath($path)
    {
        if (is_dir($path)) {
            return true;
        }
        if (mkdir($path, 0755, true)) {
            return true;
        } else {
            return false;
        }
    }


    /**数组分页
     * @param $page
     * @param $limit
     * @param $array
     * @param int $order
     * @return array
     */
    public function page_list($page, $limit, $array, $order = 0)
    {
        $page = (empty($page)) ? '1' : $page; #判断当前页面是否为空 如果为空就表示为第一页面
        $start = ($page - 1) * $limit; #计算每次分页的开始位置
        if ($order == 1) {
            $array = array_reverse($array);//倒序
        }
        $totals = count($array); //总数
        $countpage = ceil($totals / $limit); #计算总页面数
        $pagedata = array_slice($array, $start, $limit); //返回数据
        return [
            'list' => $pagedata,
            'total' => $totals,
            'countpage' => $countpage
        ];  #返回查询数据
    }

    /**格式化表大小展示
     * @param $data_length
     */
    public static function dataFormat($data_length){
        //默认B，大于等于1B小于1KB展示B，大于等于1KB小于1MB展示KB，大于等于1MB小于1GB展示MB，大于等于1GB小于1TB展示GB,以此类推...
        $b = 1;
        $kb = $b * 1024;
        $mb = $kb * 1024;
        $gb = $mb * 1024;
        $tb = $gb * 1024;
        if($data_length>=$kb&&$data_length<$mb){
            $data_length = bcdiv($data_length,$kb,2) .'KB';
        }elseif ($data_length>=$mb&&$data_length<$gb){
            $data_length = bcdiv($data_length,$mb,2) .'MB';
        }elseif ($data_length>=$gb&&$data_length<$tb){
            $data_length = bcdiv($data_length,$gb,2) .'GB';
        }elseif ($data_length>=$tb){
            $data_length = bcdiv($data_length,$tb,2) .'TB';
        }else{
            $data_length = $data_length .'B';
        }
        return $data_length;
    }


    /**
     * 析构方法，用于关闭文件资源
     */
    public function __destruct()
    {
        $this->config['compress'] ? @gzclose($this->fp) : @fclose($this->fp);
    }
}